LK

Replicated Advanced Turret AI

In Development
GitHub

The turret logic is driven by a Behavior Tree in combination with a Blackboard, which maintains and synchronizes the turret’s state according to predefined conditions.

An AIController processes sensory input through its perception system and coordinates the target selection workflow.

/*
* Constructor
*/
ASDTurretController::ASDTurretController()
{
    AIPerceptionComponent = CreateDefaultSubobject<UAIPerceptionComponent>(TEXT("AIPerceptionComponent"));

    UAISenseConfig_Sight* SightConfig = CreateDefaultSubobject<UAISenseConfig_Sight>(TEXT("SightConfig"));
    SightConfig->SightRadius = 1500.0f;
    SightConfig->LoseSightRadius = 2000.0f;
    SightConfig->PeripheralVisionAngleDegrees = 90.0f;
    SightConfig->SetMaxAge(2.0f);
    SightConfig->DetectionByAffiliation.bDetectEnemies = true;
    SightConfig->DetectionByAffiliation.bDetectNeutrals = true;
    SightConfig->DetectionByAffiliation.bDetectFriendlies = true;

    //NOTE: TimeUntilNextUpdate on AISense regulates the Tick intervalls for the sight perception
    
    AIPerceptionComponent->ConfigureSense(*SightConfig);
    AIPerceptionComponent->SetDominantSense(SightConfig->GetSenseImplementation());
}

/*
* Initializes and runs the assigned Behavior Tree while binding custom functions to perception events
*/
void ASDTurretController::OnPossess(APawn* InPawn)
{
    Super::OnPossess(InPawn);

    PossessedTurret = CastChecked<ASDTurret>(InPawn);
#if IF_WITH_EDITOR
    bDrawDebug = PossessedTurret->GetDrawDebug();
#endif
    
    // Automatically initializes the Behavior Tree and sets up the Blackboard, which can then be accessed via Blackboard.Get().
    RunBehaviorTree(BehvaviorTree);
    
    if (IsValid(AIPerceptionComponent))
    {
        AIPerceptionComponent->OnTargetPerceptionUpdated.AddDynamic(this, &ASDTurretController::OnPerceptionUpdated);
        AIPerceptionComponent->OnTargetPerceptionForgotten.AddDynamic(this, &ASDTurretController::OnPerceptionForgotten);
    }
}
/*
* Triggered when the perception component receives a new stimulus
* Registers the detected actor and adds it to the list of known targets
*/
void ASDTurretController::OnPerceptionUpdated(AActor* TargetActor, FAIStimulus Stimulus)
{
    TSubclassOf<UAISense> SenseClassByStimulus = UAIPerceptionSystem::GetSenseClassForStimulus(GetWorld(), Stimulus);

    if (SenseClassByStimulus == UAISense_Sight::StaticClass())
    {
        if (Stimulus.WasSuccessfullySensed() && IsValid(TargetActor))
            AllTargetActors.AddUnique(TargetActor);
    }
}

/*
* Triggered when the perception component loses sight of a known actor
* Removes the actor from the target pool and clears the Blackboard entry if it was set as the active target
*/
void ASDTurretController::OnPerceptionForgotten(AActor* TargetActor)
{
    RemoveTargetActor(TargetActor);

#if IF_WITH_EDITOR
    if(bDrawDebug)
        GEngine->AddOnScreenDebugMessage(-1, 2.0f, FColor::Purple, FString::Printf(TEXT("Forgot actor: %s"), *GetNameSafe(TargetActor)));
#endif
}

When a target enters perception range, a dedicated function is invoked through the AIController´s interface within the Behavior Tree task BTTask_TurretSelectTarget. This function assigns the current target in the Blackboard, enabling subsequent leaf nodes in the Behavior Tree to progress.

bool ASDTurretController::Controller_TurretSelectTarget()
{
    if (IsValid(LastTargetActor) && PossessedTurret->GetCurrentTarget() == LastTargetActor)
    {
        SetTargetActor(LastTargetActor);
        return true;
    }

    AActor* SelectedTarget = ChooseTargetActor();

    if (IsValid(SelectedTarget))
    {
        SetTargetActor(SelectedTarget);
        return true;
    }

    return false;
}

Once the current target is assigned in the Blackboard, the Behavior Tree transitions the turret into its combat state.

void ASDTurret::Character_TurretSetCombatState(bool bInCombat)
{
	switch (CurrentTurretState)
	{
	case ETurretState::Idle:
		if (bInCombat == true)
		{
			CurrentTurretState = ETurretState::Combat;
			Blackboard->SetValueAsEnum(TurretStateBlackboardValueName, static_cast<uint8>(CurrentTurretState));
			StartCombatState();
		}
		break;
	case ETurretState::Combat:
		if (bInCombat == false)
		{
			CurrentTurretState = ETurretState::Idle;
			Blackboard->SetValueAsEnum(TurretStateBlackboardValueName, static_cast<uint8>(CurrentTurretState));
			StartIdleState();
		}
		break;
	}
}

This triggers a chain of events that activate the targeting system. A timer introduces controlled delays between each step in the firing sequence, providing precise pacing of the shooting process. When the combat state is entered, the IsAiming flag is set to true, and the actor begins its ticking behavior, which encapsulates the turret’s targeting logic.

void ASDTurret::Tick(float DeltaSeconds)
{
	Super::Tick(DeltaSeconds);
	
	if (IsValid(CurrentTarget))
	{
		bIsRotatingBackToStart = false;

		if (CurrentTarget != LastProcessedTarget)
		{
			bHasStartedTargeting = false;
			bLastTargetingStatus = false;
			LastProcessedTarget = CurrentTarget;

#if WITH_EDITOR
			if (bDrawDebug)
				GEngine->AddOnScreenDebugMessage(-1, 2.f, FColor::Cyan, TEXT("New Target Detected – Resetting Targeting Status"));
#endif
		}

		Move the barrel towards the target’s position, start firing once aligned
		InterpolateToPosition(DeltaSeconds, CurrentTarget->GetTargetLocation());
		CheckIfTargetAligned();
	}
	else
	{
		// Handle loss of target
		if (bWasTargetValidLastTick)
		{
			OnTargetLost();
		}

		// Return barrel to default position
		if (bIsRotatingBackToStart)
		{
			InterpolateToPosition(DeltaSeconds, StartingLocation);

#if WITH_EDITOR
			if (bDrawDebug)
			{
				//GEngine->AddOnScreenDebugMessage(-1, 2.f, FColor::Magenta, FString::Printf(TEXT("%s rotating back to start"), *GetNameSafe(this)));

				/*GEngine->AddOnScreenDebugMessage(3, 2.f, FColor::Magenta,
					FString::Printf(TEXT("CurrentTarget: %s | StartingLocation (Local): %s"),
						*(GetMesh()->GetComponentTransform().TransformPosition(CurrentTargetLocation) - TargetOffsetVector).ToString(),
						*StartingLocation.ToString()));*/

				DrawDebugSphere(GetWorld(), StartingLocation, 20.f, 12, FColor::Green);
				DrawDebugSphere(GetWorld(), GetMesh()->GetComponentTransform().TransformPosition(CurrentTargetLocation) - TargetOffsetVector, 20.f, 12, FColor::Red);

			}
#endif

			// Disable ticking once back at the default state
			if ((GetMesh()->GetComponentTransform().TransformPosition(CurrentTargetLocation) - TargetOffsetVector).Equals(StartingLocation, 1.0f))
			{
				bIsRotatingBackToStart = false;

#if WITH_EDITOR
				if (bDrawDebug)
					GEngine->AddOnScreenDebugMessage(3, 2.f, FColor::Purple, FString::Printf(TEXT("%s arrived back at starting location"), *GetNameSafe(this)));
#endif
				SetActorTickEnabled(false);
			}
		}
	}

	bWasTargetValidLastTick = IsValid(CurrentTarget);
}
#pragma region Tick Functionality
/*
 * Rotates the barrel toward the CurrentTarget location at a defined rotation speed
 */
void ASDTurret::InterpolateToPosition(float DeltaSeconds, const FVector& InPosition)
{
	TargetLocationWorldSpace = InPosition + TargetOffsetVector;
	DesiredLocalPosition = GetMesh()->GetComponentTransform().InverseTransformPosition(TargetLocationWorldSpace);

	if (SmoothedLocalTargetLocation.IsNearlyZero())
		SmoothedLocalTargetLocation = GetAimTransform().InverseTransformPosition(GetCombatSocketLocation());

	SmoothedLocalTargetLocation = FMath::VInterpConstantTo(
		SmoothedLocalTargetLocation,
		DesiredLocalPosition,
		DeltaSeconds,
		RotationSpeedDegrees
	);

	CurrentTargetLocation = SmoothedLocalTargetLocation;

#if WithEditor
	if (bDrawDebug)
	{
		DrawDebugSphere(
			GetWorld(),
			GetMesh()->GetComponentTransform().TransformPosition(CurrentTargetLocation) - TargetOffsetVector,
			25.f,
			12,
			FColor::Red,
			false,
			-1.f,
			0,
			1.0f
		);
	}
#endif
}

/*
 * Verifies whether the barrel has aligned with the CurrentTarget
 * If alignment is reached, the shooting process is triggered
 */
void ASDTurret::CheckIfTargetAligned()
{
	const FVector MuzzleLocation = GetCombatSocketLocation();
	const FVector MuzzleForward = GetAimTransform().GetRotation().GetForwardVector();
	const FVector TargetLocation = CurrentTarget->GetActorLocation() + BarrelOffsetVector;

	const FVector DirectionToTarget = (TargetLocation - MuzzleLocation).GetSafeNormal();

	float Dot = FVector::DotProduct(MuzzleForward, DirectionToTarget);

	Dot = FMath::Clamp(Dot, -1.0f, 1.0f);

	const float AngleDeg = FMath::RadiansToDegrees(FMath::Acos(Dot));

#if WITH_EDITOR
	if (bDrawDebug)
	{
		GEngine->AddOnScreenDebugMessage(0, 0.f, FColor::Yellow, FString::Printf(TEXT("Aim Error: %.1f°"), AngleDeg));

		DrawDebugLine(GetWorld(), MuzzleLocation, MuzzleLocation + MuzzleForward * 200.f, FColor::Blue);
		DrawDebugLine(GetWorld(), MuzzleLocation, MuzzleLocation + DirectionToTarget * 200.f, FColor::Red);
	}
#endif
	
	bIsAligned = AngleDeg <= AimErrorTolerance;

	if (bIsAligned != bLastTargetingStatus)
	{
		bLastTargetingStatus = bIsAligned;

		if(bIsAligned)
			OnTargetAligned();
		else
			OnTargetLost();
	}
}

void ASDTurret::OnTargetAligned()
{
	if (bHasStartedTargeting)
		return;

	bHasStartedTargeting = true;
	ApplyEffectToSelf(TurretShootingGameplayEffect, PlayerLevel);
	StartTargeting();

#if WITH_EDITOR
	if (bDrawDebug)
		GEngine->AddOnScreenDebugMessage(-1, 2.f, FColor::Green, FString(TEXT("Aligned with Target")));
#endif
}

void ASDTurret::OnTargetLost()
{
	bHasStartedTargeting = false;
	RemoveGameplayEffectByTag(TurretShootingTags);

#if WITH_EDITOR
	if(bDrawDebug)
		GEngine->AddOnScreenDebugMessage(-1, 2.f, FColor::Red, FString(TEXT("Target was lost")));
#endif
}
#pragma endregion

Once the barrel aligns with the target, the OnTargetAligned function is invoked. It calls StartTargeting, which sets the IsTargeting flag to true and initiates the HandleShooting routine.

HandleShooting then activates the appropriate abilities defined for the turret. The abilities are resolved dynamically based on the GameplayTags currently assigned to the turret instance.

/// <summary>
/// The turret validates whether it possesses a specific GameplayTag
/// If present, the system attempts to activate the corresponding GameplayAbility
/// </summary>
void ASDTurret::HandleShooting()
{
	if (!HasGameplayTag(TurretShootingTags))
	{
#if WITH_EDITOR
		if (bDrawDebug)
			GEngine->AddOnScreenDebugMessage(-1, 2.f, FColor::Black, FString::Printf(TEXT("%s tried to Shoot without having the specified Tags"), *GetNameSafe(this)));
#endif
		return;
	}

	if (bool bActive = AbilitySystemComponent->TryActivateAbilitiesByTag(TurretShootingTags))
	{
#if WITH_EDITOR
		if (bDrawDebug)
			GEngine->AddOnScreenDebugMessage(-1, 0.f, FColor::Yellow, FString::Printf(TEXT("%s activated an Ability"), *GetNameSafe(this)));
#endif
	}
}

/// <summary>
/// Once an ability finishes, the turret restarts the targeting process
/// This reset currently depends on the ability’s cooldown system, though this may evolve in future iterations
/// </summary>
void ASDTurret::OnAbilityEnded(const FAbilityEndedData& EndedData)
{
	if (!EndedData.AbilityThatEnded)
		return;

	if (EndedData.AbilityThatEnded->GetAssetTags().HasAllExact(TurretShootingTags))
	{
#if WITH_EDITOR
		if(bDrawDebug)
			GEngine->AddOnScreenDebugMessage(-1, 0.f, FColor::Yellow, FString::Printf(TEXT("%s Ability ended on %s"), *GetNameSafe(EndedData.AbilityThatEnded), *GetNameSafe(this)));
#endif
		
		StartTargeting();
	}
}

The turret’s core logic runs entirely on the server. Only essential data is replicated to clients — namely the current target information and a small set of key boolean variables. These replicated values are then interpreted by the client-side Animation Blueprint.

void ASDTurret::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
	Super::GetLifetimeReplicatedProps(OutLifetimeProps);

	DOREPLIFETIME(ASDTurret, CurrentTargetLocation);
	DOREPLIFETIME(ASDTurret, CurrentTargetDistance);
	DOREPLIFETIME(ASDTurret, bIsAiming);
	DOREPLIFETIME(ASDTurret, bIsTargetting);
	DOREPLIFETIME(ASDTurret, bIsRotatingBackToStart);
}

Within the Animation Blueprint, the replicated values are consumed to drive turret animations and effects.

The Animation Blueprint reacts solely to the replicated boolean variables and forwards the current target location into the Control Rig’s barrel controller. Shooting itself is encapsulated within the ability:

  • An animation montage, provided by the turret, is played.
  • This montage handles sound effects, Niagara particle effects, and firing logic through animation notifies.

Because this sequence is executed entirely within the Animation Blueprint, each client independently manages its animations and visual effects, ensuring proper synchronization without excessive replication overhead.

The Turret is capable of firing multiple types of data-driven projectiles, with projectile types fully interchangeable during gameplay.

Planned features include:

  • Binding turret behavior directly to DataAssets, eliminating the need for extensive subclassing
  • Adding an upgrade menu, enabling players to enhance turret capabilities during gameplay

The project’s source code is linked on GitHub beneath the video at the top of the page.